Repository navigation
Thread-state pipeline: turns end cleanly, no hub stalls, instant and exact updates on every client - #35
Merged
Conversation
… and task results from the thread's own entries - Hub.has_msg is a tail walk that stops at the first hit; Hub.dup looks anything up only for a message the client named (Hub.turn, Hub.turnx, Bots.mentions.p all go through it, so they agree). - Mcp.last stops at the newest assistant message and thread_wait reads it from M.State.mine; Task.last reads M.State.mine too. - Task.finish looks the task up only once the step ends a turn (Task.finish.e), not on every agent line. - Laws has_msg_unnamed_skips, mcp_last_newest, task_finish_not_ended_same. The dedupe stays over the whole log: per-thread would falsify law resend_stored_once, which needs the user's consent to change.
…tart) Whenever a thread's process is retired nothing it held is left waiting: - its open asks close (AskClosed with "") and each parked request gets the empty answer (deny for an approval, dismissed for a question); a plan card parks nothing and stays (Retire.asks, Retire.answers); - a running turn ends Interrupted and a delegated child's task ends with it, so its parent hears (Retire.turn through Task.finish). Stop (turn.interrupt and MCP thread_interrupt) goes through Retire.stop; archive and delete through Retire.archive, which also clears background rows; an agent exit closes its asks beside Bg.clear. Server: a Claude process whose socket could not be duplicated is stopped and registered so its exit goes through the usual AgentGone lifecycle (no self-send from the hub fiber); a Grok one is stopped and reported as a failed start. Laws archive_running_stops, archive_idle_no_turn, archive_closes_approval, delete_running_stops, interrupt_closes_approval, interrupt_keeps_plan, interrupt_ends_child_task, gone_closes_approval, retire_answers_empty.
- A Claude result completes or fails only a running turn, and only a running turn's text streams (Claude.result.if, Claude.stream.if): the result Claude still writes after a Stop no longer turns Interrupted into Completed (a false alert, and the held queue starting), and its late deltas no longer refill the live text. Whole messages, tool results and background tasks still land. - Claude's reader tags its lines with B.Tag.of(thread, pid), so a replaced or archived process is not heard (Agent.reader/got take the tag; the exit still names the plain thread and pid). - Known limit: a stale result that arrives after a new turn started on the same process is still taken (the lines carry no turn). - Laws claude_result_after_stop_quiet, claude_result_after_stop_interrupted, claude_result_running_completes, claude_delta_after_stop_quiet, claude_delta_running_streams. - test/tools/retire_e2e.ts: Stop with a parked approval (one deny, ask closed, stays Interrupted, no late delta, queue held), archive then unarchive, and a model switch whose old process's late result is dropped.
- Carry.fresh looks past the "restart" note Carry.k files after its continue message, so a hub that dies again before the agent wrote anything does not carry the turn again (the loop the guard exists for). - Carry.one also needs the thread neither archived nor deleted (Carry.live), so archived-running ghosts in old logs stay stopped. - Laws carry_twice_once, carry_skips_archived (carry_once kept).
The hub fiber is the inbox's only reader, yet it sent into it itself: the bots' reply request at a turn's end, and BotsRefresh after a PeerSet is stored and when a signed peer request finds the directory stale. With the 4096-slot inbox full, that send parked the hub for good. These now go through Self.post, which sends on a fiber of its own (the Rpc already queued behind everything in the inbox, so the order is as before).
- Sock.send_until (host.c, host.js twin): send_bytes with one deadline for the whole frame; out of time or failed, it shuts the socket both ways. - A client's writer sends each frame with a 10 s deadline (Conn.deadline); on failure it closes the client's channel first (the hub's sends to it then end at once instead of parking on a full queue), then the socket; its reader sees the end and the client leaves. - Accepted sockets get TCP keepalive (20 s idle, 5 s x 3) and TCP_USER_TIMEOUT 30 s, so a half-open idle peer is found too. - test/tools/stall_e2e.ts: a client that never reads while another streams a flood; the other still gets the turn's end and replies, the stalled one is dropped, and a fresh client gets exactly the same log. Twice.
A client older than its hub (a TestFlight build behind, a cached web page) dropped changes with tags it does not know, and its sequence counted only those it applied, while the hub resumes a reconnect by how many lines it sent: every reconnect asked for the tail again and posted its messages twice. Ui.reset and a changes message now fold through Ui.fold.items, which ticks the read model once per unreadable item (no new event, no change to Store.apply, so store_state_is_replay holds). Phones' kept-state key unchanged (91f56ce99374e3e4). Laws ui_seq_counts_unknown, ui_seq_known_unchanged, ui_seq_log_counts_unknown.
(the hub stops a replaced process with SIGTERM; its stdin stays open while the reader holds the socket, so waiting for EOF never ended)
Ui.reset rebuilt the info map from the hub's info plus Local.keep's fixed list, so every reconnect (phones on each resume) dropped each thread's viewer: kind, path, panel, the hand toggle (letting an automatic view override the person), diff, folds, history step and comparison, the Files folder. Ui.reset.keep copies those keys from the old map when the log's origin is the one the client had (the same hub); results asked again (refs:, fm.list:) and playback (hist.play:) are left out. Local.keep is untouched: it also runs on a machine switch and a cached install, where carrying keys would leak one machine's paths into another. Laws reset_keeps_viewer, reset_keeps_hand, reset_new_origin_drops_viewer, reset_drops_results; view_test (full and incremental reconnect, diff, history, stale listing, manual protection) and hub_switch_test (colliding thread ids across hubs, back to the cached home).
…ications) The window received with U.Ui.recv, so the commands a message gives never ran: the outbox was never sent again after a reconnect, and desktop notifications never fired (U.Notify comes only from Ui.recv_act). Hear.k now goes through Nui.recv.act (recv_act, then the shot note) and Nui.sync.kd, which forces a frame when commands come. The hub's own window, which adopts the read model without a socket (Shell.adopt), now runs Nui.adopt.step, which also sends the outbox (the hub stores a resent message once). Web and phones already did this. test/nui_recv_test.bend: notify becomes NNotify; a log resends the outbox once and not after the answer; adoption resends it.
- Hubs.redraw (App.shows): any message from any paired hub can change the merged sidebar and the island, so it builds a screen; only streamed text is held to Hubs.shows (unchanged, with its laws). Unfocused completions and approvals now show at once. - A hub out of focus builds no thread, bot or viewer for the merged screen (Mob.screen.f; Hubs.screen.k reads those from the focused hub only), and the sidebar's groups are found once per screen. - Alerts: streamed text skips the before/after comparison entirely (One.got.d); far and every other message keep it. Note.was is a tail walk that stops at the first hit (it was the square of the threads). - Island and alert text read each thread's own entries (Note.island.st, Note.lines.st, Note.alerts.st over M.State.mine, keeping the thread check) instead of walking the whole log per running thread; the es interfaces stay for their laws. - Laws hubs_redraw_changes_elsewhere, hubs_redraw_delta_elsewhere, hubs_redraw_delta_other_thread, alerts_none_for_delta, island_doing_mine_same, island_doing_mine_text, hubs_screen_unfocused_thread_null; hubs_test: unfocused completion and approval redraw and alert, deltas alert nothing, connecting raises no old alerts, an unfocused hub's screen has no thread.
…er A's stall (a reset from the hub counts as the stalled client dropped)
- app.bend: shows (U.Ui.shows) and kind, taken before App.recv. - host.js renders only when the message shows or gives commands, and round-trips a message to JSON for the kept log only for log and changes (every token of every agent was a full render and a parse). - The kept log grows in place (push, no concat) and is written to localStorage at most every 2 s, on pagehide and when hidden; over quota the page keeps its copy in memory and drops only the stored one; a reload before a write catches up through since. Checked in headless Chromium against a stand-in hub streaming 60 deltas to a thread the page does not show: requestAnimationFrame calls during the stream 40 -> 0, the thread still listed, a reload shows it.
- The startup feed (FarFeed with epoch "") no longer resets the epoch
Far.start installed (Far.fed.k leaves it alone). Each mirror stored with
epoch "" is pulled again once ("" to non-empty is a change), not in a
loop.
- Each minute, a linked machine that answered is pulled through the
existing coalesced pull even when already online (Far.here.was.k), so a
lost last push is repaired within the minute.
- Fr.feed.after stops at the first line at or below since instead of
walking the rest of the feed (law far_feed_after_stops, same result).
…s focus on a visible field
Snaiderbg
pushed a commit
to Snaiderbg/Backplane
that referenced
this pull request
Oct 9, 2026
Snappier window and phones: parallel frames, changed pixels only, state kept across builds
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adversarial review of the thread-state pipeline (agent turn → hub → event store → broadcast → window, web, phones). Two Sonnet 5.5 and two GPT-6.1-Sol reviewers split the hub and client sides; each pair reached consensus; an Opus 5.5 / Sol debate produced the spec; Opus implemented it; a final Sol pass over the diff found two regressions, fixed in the last commit.
Turns finish and show state
resultor delta after Stop no longer flips state or repopulates live text.Many projects, performance
thread_waitpoll / streamed line; lookups use the thread's own entries and stop at the first hit.Instant updates, devices
recv_act: outbox resent after reconnect, desktop notifications now fire.Tests:
scripts/check.sh(All terms check, new laws for each item),scripts/test.sh73/73, native build,bridge.jsbuild, new e2etest/tools/retire_e2e.tsandtest/tools/stall_e2e.ts(all ok), newtest/nui_recv_test.bend.Deferred (next PR): sidebar indexes (biggest many-project win; changes the phones' kept-state key), true non-blocking sends, Codex/Grok spawn generations, live-text replay on reconnect, exit detection without stdout EOF,
want_newpicking another client's thread.